harden: secure extension and preset archive downloads - #3141
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens extension/preset/catalog download and extraction paths by introducing bounded reads, optional SHA-256 verification, and safer ZIP handling, then wiring those protections into the extension/preset install flows and associated tests.
Changes:
- Add
_download_securityhelpers for strict URL validation, bounded HTTP reads, SHA-256 verification, bounded ZIP-member reads, and safe ZIP extraction (zip-slip/zip-bomb/symlink defenses). - Update extension and preset download/install flows to use bounded reads, checksum verification (when
sha256is present), andsafe_extract_zip. - Update auth redirect handling and tests to reflect strict redirect enforcement and stream-like
read(size)semantics.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/_download_security.py |
New security primitives: bounded reads, ZIP extraction/member limits, checksum verification, scheme/host predicate. |
src/specify_cli/authentication/http.py |
Strict redirect validation integrated into redirect handler and exposed via open_url(..., strict_redirects=...). |
src/specify_cli/authentication/azure_devops.py |
Bounded token-response reads and strict redirect protection for client-credentials POST. |
src/specify_cli/_version.py |
Latest-release metadata fetch now uses bounded reads and strict redirects. |
src/specify_cli/_github_http.py |
GitHub release metadata resolution now uses bounded reads to avoid unbounded JSON loads. |
src/specify_cli/catalogs.py |
Catalog URL validation rejects hostless URLs before scheme checks. |
src/specify_cli/extensions/__init__.py |
Extension ZIP installs use safe_extract_zip; catalog and ZIP downloads are bounded and optionally checksum-verified. |
src/specify_cli/extensions/_commands.py |
Extension manifest pre-read from ZIP is now bounded (read_zip_member_limited). |
src/specify_cli/presets/__init__.py |
Preset ZIP installs use safe_extract_zip; catalog and ZIP downloads are bounded and optionally checksum-verified. |
src/specify_cli/presets/_commands.py |
Shared URL predicate for download/redirect validation; preset ZIP downloads use bounded reads and strict redirects for GitHub metadata resolution. |
tests/test_download_security.py |
New tests covering bounded reads, checksum verification, safe ZIP extraction, and bounded ZIP member reads. |
tests/test_authentication.py |
Tests updated for strict redirect rejection and bounded read semantics; Azure AD token tests patch opener behavior. |
tests/test_github_http.py |
Tests updated for stream-like reads; add multi-hop GitHub redirect auth-preservation test. |
tests/http_helpers.py |
Stream-backed read() mocks and an autouse fixture to route build_opener().open() through patched urlopen. |
tests/test_upgrade.py |
Add regression test asserting _fetch_latest_release_tag reads via read_response_limited(max_bytes=...). |
tests/self_upgrade_helpers.py |
Re-export the opener-routing fixture for self-upgrade test modules. |
tests/test_self_upgrade_detection.py |
Enable opener-routing fixture to keep existing urlopen patches effective. |
tests/test_self_upgrade_execution.py |
Enable opener-routing fixture to keep existing urlopen patches effective. |
tests/test_self_upgrade_verification.py |
Enable opener-routing fixture to keep existing urlopen patches effective. |
tests/test_extensions.py |
Stream-like read(size) mocks and one assertion update for host-before-scheme URL validation. |
tests/test_presets.py |
Stream-like read(size) mocks and open_url fake signature updated for strict_redirects. |
tests/integrations/test_integration_catalog.py |
One assertion updated for host-before-scheme URL validation. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 22/22 changed files
- Comments generated: 2
|
Converting to draft as #3140 has to land first |
Adopt the shared download-security primitives from github#3140 across extension and preset catalog, package, direct-URL, and ZIP-install flows: - bound catalog, package, and inline manifest reads; - verify catalog SHA-256 values when present; - replace path-only extraction with bounded traversal/symlink-safe extraction; - validate malformed hosts and ports before opening download URLs; - handle normalized trailing-backslash directory entries consistently. Redirect enforcement and checksum verification remain owned by the shared helpers already on main; this commit wires them into extension and preset behavior. Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Preflight ZIP central directories before ZipFile allocates them, bound both declared and actual payload sizes, and reject ambiguous or non-portable archive paths before extraction. Keep extension update manifest selection consistent with extraction, reject unsafe catalog-derived output filenames and malformed URL types, and escape untrusted values in download errors. Add regression coverage for parser differentials, collisions, platform-specific filenames, bounded call sites, and failure ordering. Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
99773ba to
b52570e
Compare
|
Rebased this PR onto current The refreshed diff removes the duplicated #3140 history, retains the shared redirect/checksum behavior from Validation on the published tree: full Python 3.12 suite Posted on behalf of @PascalThuet by OpenAI Codex (model: GPT-5). |
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback. Also what about workflow, workflow steps and bundle downloads? Do they need this too?
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
|
Please address Copilot feedback and fix test & lint errors |
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/specify_cli/_download_security.py:472
- Checking only equality with version 4.5 does not reliably reject ZIP64. A ZIP64 data-descriptor archive can change both “version needed” fields from 45 to 46 while retaining the 64-bit descriptor;
ZipFileaccepts it, and the preflight no longer sees any ZIP64 indicator after the sentinels/extra field are masked. Since supported STORE/DEFLATE ZIP32 entries require versions below 4.5, reject version-needed values at or above 45.
if extract_version == _ZIP64_EXTRACT_VERSION:
_raise_zip64(error_type)
src/specify_cli/extensions/_commands.py:1316
- Root selection uses an exact case-sensitive string even though candidate identity is case-folded. An archive containing
EXTENSION.YMLat the root plus a validrepo/extension.ymltherefore preflights the nested manifest, but after extractioninstall_from_zip()finds the root file asextension.ymlon a case-insensitive filesystem and installs that different, unvalidated manifest. Select/reject root aliases using the portable key and ensure installation uses the same selected member on every filesystem.
root_manifest = next(
(
name
for name, normalized_name, _path_key
in manifest_candidates
if normalized_name == "extension.yml"
),
- Files reviewed: 21/21 changed files
- Comments generated: 2
- Review effort level: Medium
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback and fix test & lint errors
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/specify_cli/agents.py:986
- Nested Copilot aliases now create parent directories, but
unregister_commands()only unlinks the agent and prompt files and does not prune parents for non-skill outputs. Removing an extension that registersgroup/runtherefore leaves.github/agents/group/and.github/prompts/group/behind. Prune empty parents up to each output root so this new nested-alias path remains uninstall-clean.
prompt_file.parent.mkdir(parents=True, exist_ok=True)
- Files reviewed: 24/24 changed files
- Comments generated: 0 new
- Review effort level: Medium
|
Thank you! |
Originally rebased onto
mainat4d3a428. The published head now includes a validated merge of currentmain(0117a7b). #3140 and #3671 are merged, so this PR reuses their shared URL, redirect, bounded-read, and checksum primitives instead of carrying the #3140 commit.What
ZipFilematerializes entries, including declared/actual entry counts, a central-directory size cap, and archive-level or per-entry ZIP64 indicators.Security and compatibility boundaries
ZipFilebecause the standard-library read path cannot enforce the requested decompressed-output bound for those methods.Validation
ab2a825,mainat0117a7b):5641 passed, 172 skipped.5427 passed, 166 skipped.793 passed.uvx ruff@0.15.0 check src tests: clean on the published merged head.git diff --check: clean on the published merged head.Deliberately out of scope
Authorship disclosure: updated on behalf of @PascalThuet by OpenAI Codex (model: GPT-5).